---
title: "Learner Bot — Nightly Cycle"
type: concept
created: 2026-04-18
updated: 2026-04-18
sources: ["raw/articles/01-data-model.md", "raw/articles/06-reading-telemetry.md", "raw/articles/09-acceptance-criteria.md"]
tags: [learner-bot, nightly, cron, reports, gaps]
---

# Nightly Cycle

The Learner Bot runs a full per-child analysis at **04:00 UTC every night** via `node-cron` (in-process). This is the heartbeat of the Pickatale intelligence loop.

## What Runs at 04:00 UTC

```mermaid
flowchart TD
    CRON["⏰ Cron: 04:00 UTC"] --> QUERY["Query all active learners<br/>(full entitlement only)"]
    QUERY --> LOOP["For each learner..."]

    LOOP --> CHECK_RUN{"Already ran today?<br/>(nightly_reports.generated_at)"}
    CHECK_RUN -->|yes| SKIP["Skip (idempotent)"]
    CHECK_RUN -->|no| READ_MEM["Read all memories<br/>vocab_gaps, assessment_results<br/>curriculum_state, reading_pattern"]

    READ_MEM --> GAPS["Select 1–3 curriculum gaps<br/>ranked by confidence + recency"]
    GAPS --> ADAPT["POST /api/v1/adapt<br/>(Adaptive Engine — recommended level)"]
    ADAPT --> TEACHER_RPT["Generate teacher_report<br/>(curriculum language, evidence list,<br/>FK progression, reasoning)"]
    TEACHER_RPT --> PARENT_DIGEST["Generate parent_digest<br/>(warm tone, celebration, home tip)"]
    PARENT_DIGEST --> STORE["INSERT nightly_reports<br/>(learner_id, report_type, content_json)"]
    STORE --> FLAGS{"Check flags..."}

    FLAGS -->|avg quiz <65% on 2+ quizzes| LOW_SCORE["INSERT teacher_notifications<br/>type='low_score'"]
    FLAGS -->|no sessions in 4+ days| INACTIVE["INSERT teacher_notifications<br/>type='inactive'"]
    FLAGS -->|no placement test| NO_PLACEMENT["INSERT teacher_notifications<br/>type='no_placement'"]
    FLAGS --> NEXT["Next learner"]
```

## What It Reads

| Source | Data |
|---|---|
| `learner_memories` | reading_level, curriculum_state, reading_pattern, engagement_signal |
| `vocabulary_gaps` | words tapped and counts |
| `assessment_results` | quiz scores, level_recommendations |
| `curriculum_state` | territory, year_level, mastered objectives, in-progress |
| Reader App (via internal API) | books_read, total_miles, recent sessions |
| [[Curriculum Mapper]] (via REST API) | objectives for territory + year_level |

## What It Writes

| Destination | Data |
|---|---|
| `nightly_reports` | teacher_report + parent_digest (JSON) |
| `teacher_notifications` | low_score, inactive, no_placement alerts |
| (Optional future) `learner_memories` | updated curriculum_state, reading_level progression |

## Prerequisite Conditions

Before the nightly cycle runs for a learner:
1. `checkEntitlement()` must return `tier = 'full'`
2. Learner must have at least 1 completed reading session
3. Not already run today (idempotency check on `nightly_reports.generated_at`)

For learners with 0 sessions → bot does NOT run. Teacher Portal shows "No activity yet" with placement test nudge.

## Error Handling

Per Sig's spec (Section 42.2):
- If **individual learner processing fails:** log to `cron_runs.error_log`, continue to next learner
- If **full nightly run crashes:** INSERT `audit_log` (action='nightly_run_failed'). Alert re-runs at 05:00 UTC once.
- All runs logged to `cron_runs` table: job_name, started_at, completed_at, items_total/ok/failed, error_log

## Related Cron Jobs

| Job | Time | Service |
|---|---|---|
| Nightly bot run | 04:00 UTC | Learner Bot |
| Telemetry retention cleanup | 03:00 UTC | Telemetry |
| Weekly parent digest | Sunday 08:00 UTC | Learner Bot |
| LRS reconciliation | 05:00 UTC | Reader App |

**v1 job design — confirmed production behavior:**
- `node-cron` in-process scheduler
- **Retry:** failed learner runs retry up to 3 times with exponential backoff (1s, 4s, 16s) before writing to `failed_runs` table
- **Dead-letter:** learners that fail all 3 attempts are logged to `failed_runs` with full error, picked up by 05:00 UTC re-run
- **Visibility:** every run writes start/end/outcome to `cron_runs` table; zero silent failures
- **Data safety:** all bot writes are transactional; a crashed run does not leave partial state
- **Horizontal scale:** single instance in v1; `cron_runs` table acts as distributed lock if multi-instance is added later

> **Needs Approval:** Migration to Bull + Redis for multi-instance scheduling. Not required for v1 single-server deployment. Trigger: when horizontal scaling is needed.
